fix(cw): prefer WASAPI host API for CW sidetone on Windows (#3193)#3241
Conversation
…#3193) Two related changes to CwSidetonePortAudioSink, both Windows-only: 1. defaultPortAudioOutputDevice(): Pa_GetDefaultOutputDevice() on Windows typically returns an MME device (first enumerated host API), which carries 50–150 ms OS-level buffering. Add a WASAPI preference pass before the MME fallback, mirroring the existing Linux JACK preference. WASAPI shared mode runs at ~10 ms, eliminating the timing jitter that makes CW sidetone sound uneven on fast keying. 2. findPortAudioOutputDevice(): a single physical device appears under multiple PortAudio host APIs on Windows (MME, DirectSound, WASAPI). The old first-match logic would either pick MME (first enumerated) or return paNoDevice with an "ambiguous" warning when all three matched. Rewrite to collect all partial-match candidates and resolve to the WASAPI entry when exactly one exists, with a clear log message. Falls back to the existing ambiguous-match warning if multiple WASAPI entries are somehow present. No change to AudioEngine.cpp or any non-Windows code path. Refs aethersdr#3193
There was a problem hiding this comment.
Thanks @M7HNF-Ian — this is a well-scoped, well-explained fix. The two changes mirror each other nicely and the comments in the code make the why obvious (Windows multi-host-API enumeration, MME latency rationale). A few notes:
Logic LGTM
findPortAudioOutputDevice()— the refactor from "first match + ambiguity flag" to "collect all + pick WASAPI" is straightforward, and the behaviour for non-Windows or exactly-one-match cases is preserved. ThepaInDevelopmentsentinel is a neat trick and the inline comment justifies it.defaultPortAudioOutputDevice()— the WASAPI preference pass on Windows is a clean mirror of the existing Linux JACK block. Nice symmetry.
One small observation (non-blocking)
start() at CwSidetonePortAudioSink.cpp:161–167 re-inspects the device's host API after defaultPortAudioOutputDevice() returns and sets m_fallbackOccurred = true / m_fallbackReason = "backend selected JACK default output" so the summary logger surfaces JACK selection as a backend substitution. The new WASAPI default-selection path doesn't get the same surfacing.
I think that's actually defensible — on Windows, WASAPI is the expected low-latency target, not an override the operator needs flagged the way JACK is on Linux — and you do emit a qCInfo at the selection site. Mentioning it only so you can confirm it's intentional. If you wanted parity, you'd add a qstrncmp(api->name, "Windows WASAPI", 14) == 0 arm to the same if (device.isNull()) block, but I don't think it's necessary.
Minor / cosmetic
- The
Candidate::apiTypefield is populated on every platform but only read inside#ifdef Q_OS_WIN. Trivially wasteful (onePaHostApiTypeIdper match candidate, on a list that's almost always tiny). Not worth changing. partials.size() - 1in theqCInfolog expression is fine — we only enter that block whenpartials.size() >= 2, so the value can't be negative.
Scope / conventions
- Single file changed, exactly as described.
- No
QSettings/AppSettingstouched; no RAII surface; no new resources to leak. - No null-deref risks introduced — every
Pa_GetHostApiInfo/Pa_GetDeviceInforeturn is checked before deref. - The
#ifdef Q_OS_WINguards around the WASAPI-specific code keep this from affecting Linux/macOS builds.
Looks ready to me. 73 from a fellow CW operator-in-spirit — fast-keying jitter is a real annoyance and this should make a noticeable difference. Thanks for splitting it out of #3194 as requested.
🤖 aethersdr-agent · cost: $5.5824 · model: claude-opus-4-7
## Summary Drop the \`check-paths\` allow-list job that gated \`check-windows\` and \`check-macos\` based on a hand-maintained set of files. Both cross-platform CI builds now run on every PR. ## Why The path-filter approach has been a chronic leaker. Every time a new file gained a platform-guarded branch (\`#ifdef Q_OS_WIN\`, \`#ifdef Q_OS_MAC\`, \`HAVE_PIPEWIRE\`, etc.) that wasn't on the allow-list, CI silently skipped the relevant cross-platform check and the regression surfaced at release tag time. The follow-up was always to add another path to the filter — and then the same class of issue would resurface elsewhere a few PRs later. Pattern receipts: - #796 — original \"MSVC issue slipped past CI\" lesson - #2633 → #2662 — macOS \`m_daxBridge\` reference inside macOS-only guard, missed - #2670 — Windows behavior change not exercised by CI - #2671 — added MainWindow.cpp/.h to filter after a miss - #2929 → #3052 — WASAPI mono-mic recovery landed unverified; added AudioEngine.cpp/.h - #3210 — re-added AudioEngine after a relapse - v26.5.3 — ClientPhaseRotator.cpp \`M_PI\` MSVC break slipped through - #3241 (yesterday) — CwSidetonePortAudioSink WASAPI fix; entire \`#ifdef Q_OS_WIN\` block never compiled by CI because the file wasn't on the filter ## Trade-off Cost: ~15-20 min of GitHub Actions runner time per PR (Windows + macOS in parallel, both cached aggressively — cold ~5 min, warm ~2-3 min). Benefit: a whole class of release-time regression we've been paying for repeatedly. ## Test plan - [x] YAML valid (\`python3 -c 'import yaml; yaml.safe_load(...)'\`) - [ ] Confirm \`check-windows\` and \`check-macos\` actually run on this PR (no longer marked \`skipping\`) - [ ] Both pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone split of Fix #2 from #3194, as requested by @jensenpat.
Problem
CwSidetonePortAudioSinkpicks audio output devices via PortAudio, but on Windows:defaultPortAudioOutputDevice()falls straight through toPa_GetDefaultOutputDevice(), which returns an MME device (the first host API PortAudio enumerates). MME carries 50–150 ms OS-level buffering, producing audible CW timing jitter on fast keying.findPortAudioOutputDevice()matches devices by name substring. On Windows, the same physical device is enumerated three times under MME, DirectSound, and WASAPI. The old code treated this as an ambiguous match and returnedpaNoDevice— causing the sidetone to fall back toCwSidetoneQAudioSinkand lose low-latency timing. When it did resolve, it picked whichever host API appeared first (MME).Fix
defaultPortAudioOutputDevice()— add a WASAPI preference pass before thePa_GetDefaultOutputDevice()fallback, mirroring the existing Linux JACK preference exactly. WASAPI shared mode runs at ~10 ms, vs 50–150 ms for MME.findPortAudioOutputDevice()— collect all partial-match candidates, then resolve to the WASAPI entry when exactly one exists. Logs a clearqCInfomessage showing which host API won. Falls back to the existing "matched multiple" warning only if multiple WASAPI entries are somehow present (pathological case).Scope
CwSidetonePortAudioSink.cpponly. No changes toAudioEngine.cpp, no RX path changes, no buffer size changes.Related